🎖️GitЯра🎖️
scripts/play-track-preflight.sh bd2863243bab6eb213401d949839a2bc74dde7e2 (bd286324) Text, 5.55 KB
T8b949e#!/usr/bin/env bash
T8b949e# Play track probe for promote.yml, two modes sharing one read path:
T8b949e#
T8b949e# preflight (default): is VERSION_CODE already live on the target track? Lets a
T8b949e# re-dispatched promotion no-op instead of creating a redundant Play submission
T8b949e# (each submission cancels and restarts any review already in flight — see the
T8b949e# Jul 2026 v2.8.0 submission-churn incident). Writes already_on_track=true|false
T8b949e# to $GITHUB_OUTPUT (or stdout when unset). Fail-open: on any API/auth error it
T8b949e# reports false and exits 0 — `fastlane supply` uses the same credentials and
T8b949e# will surface real failures.
T8b949e#
T8b949e# verify: after supply, is VERSION_CODE actually live on the destination track?
T8b949e# supply can exit 0 without promoting it (see promote.yml), so this mode is
T8b949e# fail-closed: it retries transient errors, then exits 1 unless the version
T8b949e# code is confirmed on the track.
T8b949e#
T8b949e# Usage: play-track-preflight.sh <service-account.json> <package> <track> <version_code> [preflight|verify]
Tffa657set -u
Te6edf3KEYTff7b72=Ta5d6ff"Tffd700${Te6edf31Tb4b4b4:Tb4b4b4?usageTb4b4b4:Tb4b4b4 play-track-preflight.sh <service-account.json> <package> <track> <version_code> [preflight|verify]Tffd700}Ta5d6ff"
Te6edf3PKGTff7b72=Ta5d6ff"Tffd700${Te6edf32Tb4b4b4:Tb4b4b4?missing packageTffd700}Ta5d6ff"
Te6edf3TRACKTff7b72=Ta5d6ff"Tffd700${Te6edf33Tb4b4b4:Tb4b4b4?missing trackTffd700}Ta5d6ff"
Te6edf3VERSION_CODETff7b72=Ta5d6ff"Tffd700${Te6edf34Tb4b4b4:Tb4b4b4?missing version_codeTffd700}Ta5d6ff"
Te6edf3MODETff7b72=Ta5d6ff"Tffd700${Te6edf35Tff7b72:-Te6edf3preflightTffd700}Ta5d6ff"
Te6edf3SCOPETff7b72=Ta5d6ff"https://www.googleapis.com/auth/androidpublisher"
Te6edf3OUTTff7b72=Ta5d6ff"Tffd700${Te6edf3GITHUB_OUTPUTTff7b72:-Tb4b4b4/dev/stdoutTffd700}Ta5d6ff"
Tff7b72case Ta5d6ff"Te6edf3$MODETa5d6ff" Tff7b72in
preflightTb4b4b4|verifyTff7b72) Tb4b4b4;Tb4b4b4;
*Tff7b72) Tffa657echo Ta5d6ff"Ta5d6ff::error::unknown mode 'Te6edf3$MODETa5d6ff' (expected preflight or verify)Ta5d6ff"Tb4b4b4; Tffa657exit T79c0ff2 Tb4b4b4;Tb4b4b4;
Tff7b72esac
emitTff7b72(Tff7b72) Tff7b72{
Tffa657echo Ta5d6ff"Ta5d6ffalready_on_track=Te6edf3$1Ta5d6ff" >> Ta5d6ff"Te6edf3$OUTTa5d6ff"
Tffa657exit T79c0ff0
Tff7b72}
fail_openTff7b72(Tff7b72) Tff7b72{
Tffa657echo Ta5d6ff"Ta5d6ff::warning::Play preflight failed (Te6edf3$1Ta5d6ff) — proceeding with promotion (fail-open).Ta5d6ff"
emit Tffa657false
Tff7b72}
Te6edf3TMPTff7b72=Ta5d6ff"Tff7b72$(mktemp -dTff7b72)Ta5d6ff" Tff7b72|| Tff7b72{ Tff7b72[ Ta5d6ff"Te6edf3$MODETa5d6ff" Tff7b72= Ta5d6ff"verify" Tff7b72] Tff7b72&& Tff7b72{ Tffa657echo Ta5d6ff"::error::mktemp failed"Tb4b4b4; Tffa657exit T79c0ff1Tb4b4b4; Tff7b72}Tb4b4b4; fail_open Ta5d6ff"mktemp"Tb4b4b4; Tff7b72}
Tffa657trap Ta5d6ff'rm -rf "$TMP"' EXIT
b64urlTff7b72(Tff7b72) Tff7b72{ openssl base64 -A Tb4b4b4| tr Ta5d6ff'+/' Ta5d6ff'-_' Tb4b4b4| tr -d Ta5d6ff'='Tb4b4b4; Tff7b72}
T8b949e# Reads the track and sets RESULT=true|false (is VERSION_CODE live on it, in a
T8b949e# completed or inProgress release). Returns non-zero with PROBE_ERR set on any
T8b949e# auth/API failure, deciding nothing.
probeTff7b72(Tff7b72) Tff7b72{
Te6edf3PROBE_ERRTff7b72=Ta5d6ff""
Tffa657local client_email now header claim sig token edit track_json
Te6edf3client_emailTff7b72=Tff7b72$(python3 -c Ta5d6ff'import json,sys;print(json.load(open(sys.argv[1]))["client_email"])' Ta5d6ff"Te6edf3$KEYTa5d6ff" T79c0ff2>/dev/nullTff7b72) Tffea00\
Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"unreadable service-account key"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
python3 -c Ta5d6ff'import json,sys;print(json.load(open(sys.argv[1]))["private_key"])' Ta5d6ff"Te6edf3$KEYTa5d6ff" > Ta5d6ff"Te6edf3$TMPTa5d6ff/key.pemTa5d6ff" T79c0ff2>/dev/null Tffea00\
Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"key missing private_key"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
Te6edf3nowTff7b72=Tff7b72$(date +%sTff7b72)
Te6edf3headerTff7b72=Tff7b72$(Tffa657printf Ta5d6ff'{"alg":"RS256","typ":"JWT"}' Tb4b4b4| b64urlTff7b72)
Te6edf3claimTff7b72=Tff7b72$(Tffa657printf Ta5d6ff'{"iss":"%s","scope":"%s","aud":"https://oauth2.googleapis.com/token","iat":%s,"exp":%s}' Tffea00\
Ta5d6ff"Te6edf3$client_emailTa5d6ff" Ta5d6ff"Te6edf3$SCOPETa5d6ff" Ta5d6ff"Te6edf3$nowTa5d6ff" Ta5d6ff"Tff7b72$((Te6edf3now Tff7b72+ T79c0ff600Tff7b72))Ta5d6ff" Tb4b4b4| b64urlTff7b72)
Te6edf3sigTff7b72=Tff7b72$(Tffa657printf Ta5d6ff'%s.%s' Ta5d6ff"Te6edf3$headerTa5d6ff" Ta5d6ff"Te6edf3$claimTa5d6ff" Tb4b4b4| openssl dgst -sha256 -sign Ta5d6ff"Te6edf3$TMPTa5d6ff/key.pemTa5d6ff" T79c0ff2>/dev/null Tb4b4b4| b64urlTff7b72) Tffea00\
Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"JWT signing"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
Te6edf3tokenTff7b72=Tff7b72$(curl -sf -X POST https://oauth2.googleapis.com/token Tffea00\
--data-urlencode Ta5d6ff'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' Tffea00\
--data-urlencode Ta5d6ff"Ta5d6ffassertion=Tffd700${Te6edf3headerTffd700}Ta5d6ff.Tffd700${Te6edf3claimTffd700}Ta5d6ff.Tffd700${Te6edf3sigTffd700}Ta5d6ff" T79c0ff2>/dev/null Tffea00\
Tb4b4b4| python3 -c Ta5d6ff'import json,sys;print(json.load(sys.stdin).get("access_token",""))' T79c0ff2>/dev/nullTff7b72)
Tff7b72[ -n Ta5d6ff"Te6edf3$tokenTa5d6ff" Tff7b72] Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"token exchange"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
Tffa657local Te6edf3apiTff7b72=Ta5d6ff"Ta5d6ffhttps://androidpublisher.googleapis.com/androidpublisher/v3/applications/Te6edf3$PKGTa5d6ff"
Te6edf3editTff7b72=Tff7b72$(curl -sf -X POST -H Ta5d6ff"Ta5d6ffAuthorization: Bearer Te6edf3$tokenTa5d6ff" -H Ta5d6ff'Content-Length: 0' Ta5d6ff"Te6edf3$apiTa5d6ff/editsTa5d6ff" T79c0ff2>/dev/null Tffea00\
Tb4b4b4| python3 -c Ta5d6ff'import json,sys;print(json.load(sys.stdin).get("id",""))' T79c0ff2>/dev/nullTff7b72)
Tff7b72[ -n Ta5d6ff"Te6edf3$editTa5d6ff" Tff7b72] Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"edit insert"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
T8b949e# Read-only: GET the track, then discard the edit without committing.
Te6edf3track_jsonTff7b72=Tff7b72$(curl -sf -H Ta5d6ff"Ta5d6ffAuthorization: Bearer Te6edf3$tokenTa5d6ff" Tffea00\
Ta5d6ff"Te6edf3$apiTa5d6ff/edits/Te6edf3$editTa5d6ff/tracks/Te6edf3$TRACKTa5d6ff" T79c0ff2>/dev/nullTff7b72)
curl -s -X DELETE -H Ta5d6ff"Ta5d6ffAuthorization: Bearer Te6edf3$tokenTa5d6ff" Ta5d6ff"Te6edf3$apiTa5d6ff/edits/Te6edf3$editTa5d6ff" >/dev/null T79c0ff2>Tb4b4b4&T79c0ff1
Tff7b72[ -n Ta5d6ff"Te6edf3$track_jsonTa5d6ff" Tff7b72] Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"track read"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
Te6edf3RESULTTff7b72=Tff7b72$(Tffa657printf Ta5d6ff'%s' Ta5d6ff"Te6edf3$track_jsonTa5d6ff" Tb4b4b4| python3 -c Ta5d6ff'
import json, sys
target = int(sys.argv[1])
track = json.load(sys.stdin)
for r in track.get("releases", []):
# Only live release states count — a draft or halted release still needs
# the promotion to run.
if r.get("status") in ("completed", "inProgress") \
and target in [int(c) for c in r.get("versionCodes", [])]:
print("true")
break
else:
print("false")
' Ta5d6ff"Te6edf3$VERSION_CODETa5d6ff" T79c0ff2>/dev/nullTff7b72) Tff7b72|| Tff7b72{ Te6edf3PROBE_ERRTff7b72=Ta5d6ff"track parse"Tb4b4b4; Tff7b72return T79c0ff1Tb4b4b4; Tff7b72}
Tff7b72}
Tff7b72if Tff7b72[ Ta5d6ff"Te6edf3$MODETa5d6ff" Tff7b72= Ta5d6ff"preflight" Tff7b72]Tb4b4b4; Tff7b72then
probe Tff7b72|| fail_open Ta5d6ff"Te6edf3$PROBE_ERRTa5d6ff"
Tff7b72if Tff7b72[ Ta5d6ff"Te6edf3$RESULTTa5d6ff" Tff7b72= Ta5d6ff"true" Tff7b72]Tb4b4b4; Tff7b72then
Tffa657echo Ta5d6ff"Ta5d6ffversionCode Te6edf3$VERSION_CODETa5d6ff is already live on track 'Te6edf3$TRACKTa5d6ff' — skipping promotion (no new Play submission).Ta5d6ff"
Tff7b72else
Tffa657echo Ta5d6ff"Ta5d6ffversionCode Te6edf3$VERSION_CODETa5d6ff not live on track 'Te6edf3$TRACKTa5d6ff' — promotion will proceed.Ta5d6ff"
Tff7b72fi
emit Ta5d6ff"Te6edf3$RESULTTa5d6ff"
Tff7b72fi
T8b949e# verify mode: fail-closed. Retries cover transient API errors and any lag
T8b949e# between supply's edit commit and the track read reflecting it.
Te6edf3ATTEMPTSTff7b72=T79c0ff4
Tff7b72for i Tff7b72in Tff7b72$(seq T79c0ff1 Ta5d6ff"Te6edf3$ATTEMPTSTa5d6ff"Tff7b72)Tb4b4b4; Tff7b72do
Tff7b72if probeTb4b4b4; Tff7b72then
Tff7b72if Tff7b72[ Ta5d6ff"Te6edf3$RESULTTa5d6ff" Tff7b72= Ta5d6ff"true" Tff7b72]Tb4b4b4; Tff7b72then
Tffa657echo Ta5d6ff"Ta5d6ffversionCode Te6edf3$VERSION_CODETa5d6ff confirmed live on track 'Te6edf3$TRACKTa5d6ff'.Ta5d6ff"
Tffa657exit T79c0ff0
Tff7b72fi
Te6edf3PROBE_ERRTff7b72=Ta5d6ff"Ta5d6fftrack 'Te6edf3$TRACKTa5d6ff' does not contain versionCode Te6edf3$VERSION_CODETa5d6ff"
Tff7b72fi
Tff7b72if Tff7b72[ Ta5d6ff"Te6edf3$iTa5d6ff" -lt Ta5d6ff"Te6edf3$ATTEMPTSTa5d6ff" Tff7b72]Tb4b4b4; Tff7b72then
Tffa657echo Ta5d6ff"Ta5d6ffVerify attempt Te6edf3$iTa5d6ff/Te6edf3$ATTEMPTSTa5d6ff failed (Te6edf3$PROBE_ERRTa5d6ff) — retrying in 15s.Ta5d6ff"
sleep T79c0ff15
Tff7b72fi
Tff7b72done
Tffa657echo Ta5d6ff"Ta5d6ff::error::Post-promotion verify failed: Te6edf3$PROBE_ERRTa5d6ff. fastlane supply exited 0 but the promotion did not land versionCode Te6edf3$VERSION_CODETa5d6ff on track 'Te6edf3$TRACKTa5d6ff'.Ta5d6ff"
Tffa657exit T79c0ff1
Served by rngit 1.5.0 - Generated in 0.06s